Micron Document
C.S.Burner 🪙 ━►🔥GIT Node

Commit ff69de1346d852b70da3c50cd0b084cc63218d55


Parents : 2f65bde
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-04T18:55:10-06:00

feat(config): add gitea base URL and documentation download URLs configuration; update related components and logic for dynamic URL handling

Changes
Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index fd188a78..6cc42b88 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -201,6 +201,8 @@ class ReticulumMeshChat:
auth_enabled: bool = False,
public_dir: str | None = None,
emergency: bool = False,
+ gitea_base_url: str | None = None,
+ docs_download_urls: str | None = None,
):
self.running = True
self.reticulum_config_dir = reticulum_config_dir
@@ -210,6 +212,8 @@ class ReticulumMeshChat:
self.emergency = emergency
self.auth_enabled_initial = auth_enabled
self.public_dir_override = public_dir
+ self.gitea_base_url_override = gitea_base_url
+ self.docs_download_urls_override = docs_download_urls
self.websocket_clients: list[web.WebSocketResponse] = []
# track announce timestamps for rate calculation
@@ -1177,11 +1181,15 @@ class ReticulumMeshChat:
def get_app_version() -> str:
return app_version
- def get_lxst_version(self) -> str:
+ @staticmethod
+ def get_package_version(package_name: str, default: str = "unknown") -> str:
try:
- return importlib.metadata.version("lxst")
+ return importlib.metadata.version(package_name)
except Exception:
- return getattr(LXST, "__version__", "unknown")
+ return default
+
+ def get_lxst_version(self) -> str:
+ return self.get_package_version("lxst", getattr(LXST, "__version__", "unknown"))
# automatically announces based on user config
async def announce_loop(self, session_id, context=None):
@@ -2272,9 +2280,16 @@ class ReticulumMeshChat:
if not url:
return web.json_response({"error": "URL is required"}, status=400)
- # Restrict to GitHub for safety
- if not url.startswith("https://git.quad4.io/") and not url.startswith(
- "https://objects.githubusercontent.com/"
+ # Restrict to allowed sources for safety
+ gitea_url = "https://git.quad4.io"
+ if self.current_context and self.current_context.config:
+ gitea_url = self.current_context.config.gitea_base_url.get()
+
+ if (
+ not url.startswith(gitea_url + "/")
+ and not url.startswith("https://git.quad4.io/")
+ and not url.startswith("https://github.com/")
+ and not url.startswith("https://objects.githubusercontent.com/")
):
return web.json_response({"error": "Invalid download URL"}, status=403)
@@ -3172,21 +3187,21 @@ class ReticulumMeshChat:
"lxst_version": self.get_lxst_version(),
"python_version": platform.python_version(),
"dependencies": {
- "aiohttp": importlib.metadata.version("aiohttp"),
- "aiohttp_session": importlib.metadata.version(
+ "aiohttp": self.get_package_version("aiohttp"),
+ "aiohttp_session": self.get_package_version(
"aiohttp-session"
),
- "cryptography": importlib.metadata.version("cryptography"),
- "psutil": importlib.metadata.version("psutil"),
- "requests": importlib.metadata.version("requests"),
- "websockets": importlib.metadata.version("websockets"),
+ "cryptography": self.get_package_version("cryptography"),
+ "psutil": self.get_package_version("psutil"),
+ "requests": self.get_package_version("requests"),
+ "websockets": self.get_package_version("websockets"),
"audioop_lts": (
- importlib.metadata.version("audioop-lts")
+ self.get_package_version("audioop-lts")
if sys.version_info >= (3, 13)
else "n/a"
),
- "ply": importlib.metadata.version("ply"),
- "bcrypt": importlib.metadata.version("bcrypt"),
+ "ply": self.get_package_version("ply"),
+ "bcrypt": self.get_package_version("bcrypt"),
},
"storage_path": self.storage_path,
"database_path": self.database_path,
@@ -7290,16 +7305,59 @@ class ReticulumMeshChat:
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+
# CSP: allow localhost for development and Electron, websockets, and blob URLs
# Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed,
# and allow framing ourselves for the docs page.
+ gitea_url = "https://git.quad4.io"
+ connect_sources = [
+ "'self'",
+ "ws://localhost:*",
+ "wss://localhost:*",
+ "blob:",
+ "https://*.tile.openstreetmap.org",
+ "https://tile.openstreetmap.org",
+ "https://nominatim.openstreetmap.org",
+ ]
+
+ if self.current_context and self.current_context.config:
+ # Add configured Gitea base URL
+ gitea_url = self.current_context.config.gitea_base_url.get()
+ if gitea_url not in connect_sources:
+ connect_sources.append(gitea_url)
+
+ # Add configured docs download URLs domains
+ docs_urls_str = self.current_context.config.docs_download_urls.get()
+ docs_urls = [
+ u.strip()
+ for u in docs_urls_str.replace("\n", ",").split(",")
+ if u.strip()
+ ]
+ for url in docs_urls:
+ try:
+ from urllib.parse import urlparse
+
+ parsed = urlparse(url)
+ if parsed.netloc:
+ domain = f"{parsed.scheme}://{parsed.netloc}"
+ if domain not in connect_sources:
+ connect_sources.append(domain)
+
+ # If GitHub is used, also allow objects.githubusercontent.com for redirects
+ if "github.com" in domain:
+ content_domain = "https://objects.githubusercontent.com"
+ if content_domain not in connect_sources:
+ connect_sources.append(content_domain)
+ except Exception: # noqa: S110
+ pass
+
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
"font-src 'self' data:; "
- "connect-src 'self' ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io; "
+ f"connect-src {' '.join(connect_sources)}; "
"media-src 'self' blob:; "
"worker-src 'self' blob:; "
"frame-src 'self'; "
@@ -10153,6 +10211,18 @@ def main():
default=os.environ.get("MESHCHAT_PUBLIC_DIR"),
help="Path to the directory containing the frontend static files (default: bundled public folder). Can also be set via MESHCHAT_PUBLIC_DIR environment variable.",
)
+ parser.add_argument(
+ "--gitea-base-url",
+ type=str,
+ default=os.environ.get("MESHCHAT_GITEA_BASE_URL"),
+ help="Base URL for Gitea instance (default: https://git.quad4.io). Can also be set via MESHCHAT_GITEA_BASE_URL environment variable.",
+ )
+ parser.add_argument(
+ "--docs-download-urls",
+ type=str,
+ default=os.environ.get("MESHCHAT_DOCS_DOWNLOAD_URLS"),
+ help="Comma-separated list of URLs to download documentation from. Can also be set via MESHCHAT_DOCS_DOWNLOAD_URLS environment variable.",
+ )
parser.add_argument(
"--test-exception-message",
type=str,
@@ -10278,6 +10348,8 @@ def main():
auth_enabled=args.auth,
public_dir=args.public_dir,
emergency=args.emergency,
+ gitea_base_url=args.gitea_base_url,
+ docs_download_urls=args.docs_download_urls,
)
# update recovery with known paths

diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 8ac01225..c86d249a 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -118,6 +118,14 @@ class ConfigManager:
"initial_docs_download_attempted",
False,
)
+ self.gitea_base_url = self.StringConfig(
+ self, "gitea_base_url", "https://git.quad4.io"
+ )
+ self.docs_download_urls = self.StringConfig(
+ self,
+ "docs_download_urls",
+ "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip,https://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip",
+ )
# desktop config
self.desktop_open_calls_in_separate_window = self.BoolConfig(

diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index 513839e5..954f91a3 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -86,7 +86,10 @@ class DocsManager:
try:
# Try symlink first as it's efficient
- os.symlink(version_path, self.docs_dir)
+ # We use a relative path for the symlink target to make the storage directory portable
+ # version_path is relative to CWD, so we need it relative to the parent of self.docs_dir
+ rel_target = os.path.relpath(version_path, os.path.dirname(self.docs_dir))
+ os.symlink(rel_target, self.docs_dir)
except (OSError, AttributeError):
# Fallback to copy
shutil.copytree(version_path, self.docs_dir)
@@ -440,54 +443,68 @@ class DocsManager:
self.download_progress = 0
self.last_error = None
- try:
- # We use the reticulum_website repository which contains the built HTML docs
- # Default to git.quad4.io as requested
- url = "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"
- zip_path = os.path.join(self.docs_base_dir, "website.zip")
+ # Get URLs from config
+ urls_str = self.config.docs_download_urls.get()
+ urls = [u.strip() for u in urls_str.replace("\n", ",").split(",") if u.strip()]
+ if not urls:
+ urls = ["https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"]
- # Download ZIP
- response = requests.get(url, stream=True, timeout=60)
- response.raise_for_status()
+ last_exception = None
+ for url in urls:
+ try:
+ logging.info(f"Attempting to download docs from {url}")
+ zip_path = os.path.join(self.docs_base_dir, "website.zip")
+
+ # Download ZIP
+ response = requests.get(url, stream=True, timeout=60)
+ response.raise_for_status()
+
+ total_size = int(response.headers.get("content-length", 0))
+ downloaded_size = 0
+
+ with open(zip_path, "wb") as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if chunk:
+ f.write(chunk)
+ downloaded_size += len(chunk)
+ if total_size > 0:
+ self.download_progress = int(
+ (downloaded_size / total_size) * 90
+ )
- total_size = int(response.headers.get("content-length", 0))
- downloaded_size = 0
+ # Extract
+ self.download_status = "extracting"
+ # For automatic downloads from git, we'll use a timestamp as version if none provided
+ if version == "latest":
+ import time
- with open(zip_path, "wb") as f:
- for chunk in response.iter_content(chunk_size=8192):
- if chunk:
- f.write(chunk)
- downloaded_size += len(chunk)
- if total_size > 0:
- self.download_progress = int(
- (downloaded_size / total_size) * 90
- )
+ version = f"git-{int(time.time())}"
- # Extract
- self.download_status = "extracting"
- # For automatic downloads from git, we'll use a timestamp as version if none provided
- if version == "latest":
- import time
+ self._extract_docs(zip_path, version)
- version = f"git-{int(time.time())}"
+ # Cleanup
+ if os.path.exists(zip_path):
+ os.remove(zip_path)
- self._extract_docs(zip_path, version)
+ self.config.docs_downloaded.set(True)
+ self.download_progress = 100
+ self.download_status = "completed"
- # Cleanup
- if os.path.exists(zip_path):
- os.remove(zip_path)
+ # Switch to the new version
+ self.switch_version(version)
+ return # Success, exit task
- self.config.docs_downloaded.set(True)
- self.download_progress = 100
- self.download_status = "completed"
-
- # Switch to the new version
- self.switch_version(version)
+ except Exception as e:
+ logging.warning(f"Failed to download docs from {url}: {e}")
+ last_exception = e
+ if os.path.exists(os.path.join(self.docs_base_dir, "website.zip")):
+ os.remove(os.path.join(self.docs_base_dir, "website.zip"))
+ continue # Try next URL
- except Exception as e:
- self.last_error = str(e)
- self.download_status = "error"
- logging.exception(f"Failed to update docs: {e}")
+ # If we got here, all URLs failed
+ self.last_error = str(last_exception)
+ self.download_status = "error"
+ logging.error(f"All docs download sources failed. Last error: {last_exception}")
def upload_zip(self, zip_bytes, version):
self.download_status = "extracting"

diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 2d3c2d5c..78f4d6ab 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -129,6 +129,19 @@ class IdentityContext:
# 3. Initialize Config and Managers
self.config = ConfigManager(self.database)
+
+ # Apply overrides from CLI/ENV if provided
+ if (
+ hasattr(self.app, "gitea_base_url_override")
+ and self.app.gitea_base_url_override
+ ):
+ self.config.gitea_base_url.set(self.app.gitea_base_url_override)
+ if (
+ hasattr(self.app, "docs_download_urls_override")
+ and self.app.docs_download_urls_override
+ ):
+ self.config.docs_download_urls.set(self.app.docs_download_urls_override)
+
self.message_handler = MessageHandler(self.database)
self.announce_manager = AnnounceManager(self.database)
self.archiver_manager = ArchiverManager(self.database)

diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index 08ea7667..1779df76 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -141,19 +141,12 @@ class TelephoneManager:
self.call_start_time = time.time()
self.call_was_established = True
- # Clear initiation status as soon as call is established
- self._update_initiation_status(None, None)
-
# Track per-call stats from the active link (uses RNS Link counters)
link = getattr(self.telephone, "active_call", None)
self.call_stats = {
"link": link,
}
- # Recording disabled for now due to stability issues with LXST
- # if self.config_manager and self.config_manager.call_recording_enabled.get():
- # self.start_recording()
-
if self.on_established_callback:
self.on_established_callback(caller_identity)
@@ -351,6 +344,14 @@ class TelephoneManager:
await asyncio.sleep(3)
raise
finally:
+ # If call was successful, keep status for a moment to prevent UI flicker
+ # while the frontend picks up the new active_call state
+ if (
+ self.telephone
+ and self.telephone.active_call
+ and self.telephone.call_status == 6
+ ):
+ await asyncio.sleep(1.5)
self._update_initiation_status(None, None)
def mute_transmit(self):

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 68810c7c..c0a8642b 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -50,7 +50,7 @@
{{ $t("app.custom_fork_by") }}
<a
target="_blank"
- href="https://git.quad4.io/Sudo-Ivan"
+ :href="`${giteaBaseUrl}/Sudo-Ivan`"
class="text-blue-500 dark:text-blue-300 hover:underline"
>Sudo-Ivan</a
>
@@ -573,6 +573,9 @@ export default {
};
},
computed: {
+ giteaBaseUrl() {
+ return this.config?.gitea_base_url || "https://git.quad4.io";
+ },
currentPopoutType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index fca70f51..abeb9910 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2304,7 +2304,6 @@ export default {
const response = await window.axios.get("/api/v1/telephone/status");
const oldCall = this.activeCall;
const newCall = response.data.active_call;
- const callStatus = response.data.call_status;
// Sync local mute state from backend
if (newCall) {
@@ -2319,14 +2318,6 @@ export default {
this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name;
- // If no active call and status is idle/busy/rejected/available, clear stale initiation UI
- const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus);
- if (isIdleState && this.initiationStatus) {
- this.initiationStatus = null;
- this.initiationTargetHash = null;
- this.initiationTargetName = null;
- }
-
if (this.activeCall?.is_voicemail) {
this.wasVoicemail = true;
}

diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue
index c5af436d..52c4e67b 100644
--- a/meshchatx/src/frontend/components/docs/DocsPage.vue
+++ b/meshchatx/src/frontend/components/docs/DocsPage.vue
@@ -367,12 +367,48 @@
<MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mx-auto mb-3" />
<div class="text-lg font-bold mb-2">{{ $t("docs.error") }}</div>
<div class="text-sm opacity-80">{{ status.last_error }}</div>
- <button
- class="mt-6 px-6 py-2 bg-red-600 text-white rounded-xl text-xs font-bold hover:bg-red-700 transition-colors"
- @click="updateDocs"
- >
- Retry Download
- </button>
+ <div class="flex flex-col gap-4 mt-6">
+ <button
+ class="px-6 py-2.5 bg-red-600 text-white rounded-xl text-xs font-bold hover:bg-red-700 transition-colors shadow-lg"
+ @click="updateDocs"
+ >
+ Retry Download
+ </button>
+
+ <div class="relative py-2">
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
+ <div class="w-full border-t border-red-200 dark:border-red-900/50"></div>
+ </div>
+ <div class="relative flex justify-center text-[10px] uppercase font-bold">
+ <span class="bg-red-50 dark:bg-zinc-900 px-2 text-red-400"
+ >or use alternate source</span
+ >
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <input
+ v-model="alternateDocsUrl"
+ type="text"
+ class="w-full px-4 py-2.5 bg-white dark:bg-zinc-800 border border-red-200 dark:border-red-900/50 rounded-xl text-xs focus:outline-none focus:ring-2 focus:ring-red-500/20 text-gray-900 dark:text-zinc-100"
+ placeholder="https://mirror.example.com/reticulum_docs.zip"
+ />
+ <button
+ class="w-full px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl text-xs font-bold hover:opacity-90 transition-opacity disabled:opacity-50"
+ :disabled="!alternateDocsUrl"
+ @click="addAndRetryDocs"
+ >
+ Add Source & Retry
+ </button>
+ </div>
+
+ <RouterLink
+ :to="{ name: 'settings' }"
+ class="text-[10px] font-bold text-red-500/60 hover:text-red-500 uppercase tracking-widest transition-colors"
+ >
+ Manage all sources in settings
+ </RouterLink>
+ </div>
</div>
</div>
@@ -535,6 +571,7 @@ export default {
meshchatxDocs: [],
selectedDocPath: null,
selectedDocContent: null,
+ alternateDocsUrl: "",
languages: {
en: "English",
de: "Deutsch",
@@ -636,6 +673,27 @@ export default {
console.error("Failed to trigger docs update:", error);
}
},
+ async addAndRetryDocs() {
+ if (!this.alternateDocsUrl) return;
+ try {
+ // Get current config
+ const configResponse = await window.axios.get("/api/v1/config");
+ const currentUrls = configResponse.data.config.docs_download_urls || "";
+ const newUrls = currentUrls ? `${currentUrls},${this.alternateDocsUrl}` : this.alternateDocsUrl;
+
+ // Update config
+ await window.axios.patch("/api/v1/config", {
+ docs_download_urls: newUrls,
+ });
+
+ // Clear input and retry
+ this.alternateDocsUrl = "";
+ await this.updateDocs();
+ } catch (error) {
+ console.error("Failed to add alternate source:", error);
+ ToastUtils.error("Failed to update documentation sources");
+ }
+ },
async switchVersion(version) {
try {
await window.axios.post("/api/v1/docs/switch", { version });

diff --git a/meshchatx/src/frontend/components/forms/Toggle.vue b/meshchatx/src/frontend/components/forms/Toggle.vue
index 01463204..3fe2501d 100644
--- a/meshchatx/src/frontend/components/forms/Toggle.vue
+++ b/meshchatx/src/frontend/components/forms/Toggle.vue
@@ -1,11 +1,16 @@
<template>
- <label :for="id" class="relative inline-flex items-center cursor-pointer">
+ <label
+ :for="id"
+ class="relative inline-flex items-center"
+ :class="disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'"
+ >
<input
:id="id"
type="checkbox"
:checked="modelValue"
+ :disabled="disabled"
class="sr-only peer"
- @change="$emit('update:modelValue', $event.target.checked)"
+ @change="!disabled && $emit('update:modelValue', $event.target.checked)"
/>
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
@@ -30,6 +35,10 @@ export default {
type: String,
default: null,
},
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
},
emits: ["update:modelValue"],
};

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 920aaff1..8c2456f6 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -881,47 +881,96 @@
</div>
<!-- no peer selected -->
- <div v-else class="flex flex-col h-full items-center justify-center">
- <div class="w-full max-w-md px-4">
- <div class="mb-6 text-center">
+ <div v-else class="flex flex-col h-full overflow-y-auto bg-gray-50/50 dark:bg-zinc-950/50">
+ <div class="max-w-4xl mx-auto w-full px-4 py-8 sm:py-12 flex flex-col items-center">
+ <!-- welcome header -->
+ <div class="text-center mb-12">
<div
- class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center"
+ class="inline-flex items-center justify-center p-4 rounded-3xl bg-blue-600 shadow-xl shadow-blue-500/20 mb-6"
>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="w-8 h-8 text-blue-600 dark:text-blue-400"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
- />
- </svg>
+ <MaterialDesignIcon icon-name="message-text" class="size-10 text-white" />
</div>
+ <h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight mb-3">
+ {{ $t("messages.no_active_chat") }}
+ </h1>
+ <p class="text-gray-500 dark:text-zinc-400 max-w-sm mx-auto">
+ {{ $t("messages.select_peer_or_enter_address") }}
+ </p>
+ </div>
+
+ <!-- main actions grid -->
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 w-full mb-12">
+ <button
+ type="button"
+ class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
+ @click="focusComposeInput"
+ >
+ <div
+ class="size-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 text-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-6" />
+ </div>
+ <span class="text-sm font-bold text-gray-900 dark:text-zinc-100">New Message</span>
+ </button>
+
+ <button
+ type="button"
+ class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
+ @click="syncPropagationNode"
+ >
+ <div
+ class="size-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform"
+ :class="{ 'animate-spin': isSyncingPropagationNode }"
+ >
+ <MaterialDesignIcon icon-name="sync" class="size-6" />
+ </div>
+ <span class="text-sm font-bold text-gray-900 dark:text-zinc-100">{{
+ isSyncingPropagationNode ? "Syncing..." : "Sync Node"
+ }}</span>
+ </button>
+
+ <button
+ type="button"
+ class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
+ @click="copyMyAddress"
+ >
+ <div
+ class="size-12 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 flex items-center justify-center group-hover:scale-110 transition-transform"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-6" />
+ </div>
+ <span class="text-sm font-bold text-gray-900 dark:text-zinc-100">My Address</span>
+ </button>
+
+ <button
+ type="button"
+ class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
+ @click="$router.push({ name: 'identities' })"
+ >
+ <div
+ class="size-12 rounded-2xl bg-purple-50 dark:bg-purple-900/20 text-purple-600 flex items-center justify-center group-hover:scale-110 transition-transform"
+ >
+ <MaterialDesignIcon icon-name="account-multiple" class="size-6" />
+ </div>
+ <span class="text-sm font-bold text-gray-900 dark:text-zinc-100">Identities</span>
+ </button>
</div>
- <h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">
- {{ $t("messages.no_active_chat") }}
- </h3>
- <p class="text-sm text-gray-500 dark:text-zinc-400 mb-8">
- {{ $t("messages.select_peer_or_enter_address") }}
- </p>
-
- <!-- latest chats grid (desktop only) -->
- <div v-if="!isMobile && latestConversations.length > 0" class="w-full max-w-2xl mb-8">
- <div class="flex items-center justify-between mb-4">
- <h4 class="text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest">
- Latest Chats
- </h4>
+
+ <!-- latest chats section -->
+ <div v-if="latestConversations.length > 0" class="w-full mb-12">
+ <div class="flex items-center justify-between mb-6">
+ <h2
+ class="text-sm font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest flex items-center gap-2"
+ >
+ <MaterialDesignIcon icon-name="history" class="size-4" />
+ Latest Conversations
+ </h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="chat in latestConversations"
:key="chat.destination_hash"
- class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
+ class="group cursor-pointer p-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-3xl hover:border-blue-500/50 hover:shadow-xl transition-all flex items-center gap-4"
@click="$emit('update:selectedPeer', chat)"
>
<div class="flex-shrink-0">
@@ -933,91 +982,104 @@
: 'account'
"
:icon-foreground-colour="
- chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour
- ? chat.lxmf_user_icon.foreground_colour
- : ''
+ chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''
"
:icon-background-colour="
- chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour
- ? chat.lxmf_user_icon.background_colour
- : ''
+ chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''
"
icon-class="size-12 sm:size-14"
/>
</div>
<div class="flex-1 min-w-0">
- <div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
- {{ chat.custom_display_name ?? chat.display_name }}
+ <div class="flex items-center justify-between gap-2">
+ <div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
+ {{ chat.custom_display_name ?? chat.display_name }}
+ </div>
+ <div class="text-[10px] text-gray-400 dark:text-zinc-500 whitespace-nowrap">
+ {{ formatTimeAgo(chat.updated_at) }}
+ </div>
</div>
<div class="text-xs text-gray-500 dark:text-zinc-500 truncate mt-0.5">
{{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }}
</div>
</div>
- <v-icon
- icon="mdi-chevron-right"
- size="18"
- class="text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
- ></v-icon>
+ <MaterialDesignIcon
+ icon-name="chevron-right"
+ class="size-5 text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
+ />
</div>
</div>
</div>
- <!-- compose message input -->
- <div class="w-full relative">
- <input
- id="compose-input"
- ref="compose-input"
- v-model="composeAddress"
- :readonly="isSendingMessage"
- type="text"
- class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
- placeholder="Enter LXMF address..."
- @keydown.enter.exact.prevent="onComposeEnterPressed"
- @keydown.up.prevent="handleComposeInputUp"
- @keydown.down.prevent="handleComposeInputDown"
- @focus="isComposeInputFocused = true"
- @blur="onComposeInputBlur"
- />
- <!-- Suggestions Dropdown -->
- <div
- v-if="isComposeInputFocused && composeSuggestions.length > 0"
- class="absolute z-50 left-0 right-0 bottom-full mb-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
- >
+ <!-- address input composer -->
+ <div class="w-full max-w-xl">
+ <div class="relative group">
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
+ <MaterialDesignIcon
+ icon-name="at"
+ class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
+ />
+ </div>
+ <input
+ id="compose-input"
+ ref="compose-input"
+ v-model="composeAddress"
+ :readonly="isSendingMessage"
+ type="text"
+ class="w-full bg-white dark:bg-zinc-900 border-2 border-gray-100 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-base rounded-3xl focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 pl-12 pr-4 py-4 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-600 font-medium"
+ placeholder="Enter LXMF address to start a conversation..."
+ @keydown.enter.exact.prevent="onComposeEnterPressed"
+ @keydown.up.prevent="handleComposeInputUp"
+ @keydown.down.prevent="handleComposeInputDown"
+ @focus="isComposeInputFocused = true"
+ @blur="onComposeInputBlur"
+ />
+
+ <!-- Suggestions Dropdown -->
<div
- v-for="(suggestion, index) in composeSuggestions"
- :key="suggestion.hash"
- class="px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors"
- :class="[
- index === selectedComposeSuggestionIndex
- ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
- : 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
- ]"
- @mousedown.prevent="selectComposeSuggestion(suggestion)"
+ v-if="isComposeInputFocused && composeSuggestions.length > 0"
+ class="absolute z-50 left-0 right-0 bottom-full mb-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300"
>
- <div
- class="shrink-0 size-8 rounded-full flex items-center justify-center text-xs"
- :class="
- suggestion.type === 'contact'
- ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
- : 'bg-gray-100 dark:bg-zinc-800 text-gray-500'
- "
- >
- <MaterialDesignIcon :icon-name="suggestion.icon" class="size-4" />
- </div>
- <div class="flex-1 min-w-0">
- <div class="text-sm font-bold truncate">
- {{ suggestion.name }}
- </div>
- <div class="text-[10px] font-mono opacity-50 truncate">
- {{ formatDestinationHash(suggestion.hash) }}
+ <div class="p-2 space-y-1">
+ <div
+ v-for="(suggestion, index) in composeSuggestions"
+ :key="suggestion.hash"
+ class="px-4 py-3 flex items-center gap-3 cursor-pointer rounded-2xl transition-all"
+ :class="[
+ index === selectedComposeSuggestionIndex
+ ? 'bg-blue-600 text-white shadow-lg'
+ : 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
+ ]"
+ @mousedown.prevent="selectComposeSuggestion(suggestion)"
+ >
+ <div
+ class="shrink-0 size-10 rounded-xl flex items-center justify-center"
+ :class="[
+ index === selectedComposeSuggestionIndex
+ ? 'bg-white/20'
+ : suggestion.type === 'contact'
+ ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
+ : 'bg-gray-100 dark:bg-zinc-800 text-gray-500',
+ ]"
+ >
+ <MaterialDesignIcon :icon-name="suggestion.icon" class="size-5" />
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="text-sm font-bold truncate">
+ {{ suggestion.name }}
+ </div>
+ <div class="text-[10px] font-mono opacity-60 truncate">
+ {{ formatDestinationHash(suggestion.hash) }}
+ </div>
+ </div>
+ <div
+ v-if="suggestion.type === 'contact'"
+ class="text-[10px] uppercase font-black tracking-widest opacity-40 px-2 py-1 rounded-md bg-black/5"
+ >
+ Contact
+ </div>
</div>
</div>
- <div
- v-if="suggestion.type === 'contact'"
- class="text-[10px] uppercase font-bold tracking-widest opacity-30"
- >
- Contact
- </div>
</div>
</div>
</div>
@@ -1097,10 +1159,157 @@
<MaterialDesignIcon icon-name="close" class="size-6" />
</button>
</div>
- <div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40">
- <pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{
- JSON.stringify(rawMessageData, null, 2)
- }}</pre>
+ <div class="p-0 overflow-y-auto bg-gray-50 dark:bg-zinc-950 flex-grow">
+ <div class="p-6 space-y-6">
+ <!-- header / status info -->
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Message ID</label
+ >
+ <div class="text-sm font-mono text-gray-900 dark:text-zinc-200">
+ {{ rawMessageData.id }}
+ </div>
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >State</label
+ >
+ <div class="flex items-center gap-2">
+ <span
+ class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset"
+ :class="
+ rawMessageData.state === 'delivered'
+ ? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400'
+ : 'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-900/30 dark:text-blue-400'
+ "
+ >
+ {{ rawMessageData.state }}
+ </span>
+ <span v-if="rawMessageData.is_incoming" class="text-[10px] text-gray-400"
+ >Incoming</span
+ >
+ <span v-else class="text-[10px] text-gray-400">Outbound</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Message Hash</label
+ >
+ <div
+ class="text-sm font-mono break-all text-gray-900 dark:text-zinc-200 bg-white dark:bg-zinc-900 p-2 rounded border border-gray-100 dark:border-zinc-800"
+ >
+ {{ rawMessageData.hash }}
+ </div>
+ </div>
+
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Source Hash</label
+ >
+ <div class="text-xs font-mono break-all text-gray-900 dark:text-zinc-200">
+ {{ rawMessageData.source_hash }}
+ </div>
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Destination Hash</label
+ >
+ <div class="text-xs font-mono break-all text-gray-900 dark:text-zinc-200">
+ {{ rawMessageData.destination_hash }}
+ </div>
+ </div>
+ </div>
+
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Method</label
+ >
+ <div class="text-sm text-gray-900 dark:text-zinc-200 capitalize">
+ {{ rawMessageData.method }}
+ </div>
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >RSSI</label
+ >
+ <div class="text-sm text-gray-900 dark:text-zinc-200">
+ {{ rawMessageData.rssi || "N/A" }}
+ </div>
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >SNR</label
+ >
+ <div class="text-sm text-gray-900 dark:text-zinc-200">
+ {{ rawMessageData.snr || "N/A" }}
+ </div>
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Attempts</label
+ >
+ <div class="text-sm text-gray-900 dark:text-zinc-200">
+ {{ rawMessageData.delivery_attempts }}
+ </div>
+ </div>
+ </div>
+
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Content / App Data</label
+ >
+ <div
+ class="text-xs font-mono bg-white dark:bg-zinc-900 p-3 rounded border border-gray-100 dark:border-zinc-800 whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300"
+ >
+ {{ rawMessageData.content }}
+ </div>
+ </div>
+
+ <div v-if="rawMessageData.raw_uri" class="space-y-1">
+ <label
+ class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
+ >Raw LXMF URI</label
+ >
+ <div
+ class="text-[10px] font-mono bg-white dark:bg-zinc-900 p-2 rounded border border-gray-100 dark:border-zinc-800 break-all text-gray-600 dark:text-zinc-400"
+ >
+ {{ rawMessageData.raw_uri }}
+ </div>
+ </div>
+
+ <!-- JSON fallback for full detail -->
+ <details class="group">
+ <summary
+ class="flex items-center gap-2 cursor-pointer text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
+ >
+ <MaterialDesignIcon
+ icon-name="chevron-right"
+ class="size-4 group-open:rotate-90 transition-transform"
+ />
+ View Full JSON Object
+ </summary>
+ <div class="mt-2 p-4 bg-black/5 dark:bg-black/20 rounded-lg overflow-x-auto">
+ <pre class="text-[10px] font-mono text-gray-600 dark:text-zinc-400">{{
+ JSON.stringify(rawMessageData, null, 2)
+ }}</pre>
+ </div>
+ </details>
+ </div>
</div>
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
<button
@@ -1233,9 +1442,21 @@ export default {
rawMessageData: null,
hasTranslator: false,
translatorLanguages: [],
+ propagationNodeStatus: null,
+ propagationStatusInterval: null,
};
},
computed: {
+ isSyncingPropagationNode() {
+ return [
+ "path_requested",
+ "link_establishing",
+ "link_established",
+ "request_sent",
+ "receiving",
+ "response_received",
+ ].includes(this.propagationNodeStatus?.state);
+ },
blockedDestinations() {
return GlobalState.blockedDestinations;
},
@@ -1387,6 +1608,9 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
+ if (this.propagationStatusInterval) {
+ clearInterval(this.propagationStatusInterval);
+ }
},
mounted() {
// listen for websocket messages
@@ -1400,8 +1624,42 @@ export default {
// fetch contacts for suggestions
this.fetchContacts();
+
+ // fetch propagation status
+ this.updatePropagationNodeStatus();
+ this.propagationStatusInterval = setInterval(() => {
+ this.updatePropagationNodeStatus();
+ }, 2000);
},
methods: {
+ async updatePropagationNodeStatus() {
+ try {
+ const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
+ this.propagationNodeStatus = response.data.propagation_node_status;
+ } catch {
+ // do nothing on error
+ }
+ },
+ async syncPropagationNode() {
+ GlobalEmitter.emit("sync-propagation-node");
+ },
+ async copyMyAddress() {
+ try {
+ await navigator.clipboard.writeText(this.myLxmfAddressHash);
+ ToastUtils.success("Your LXMF address copied to clipboard");
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error("Failed to copy address");
+ }
+ },
+ focusComposeInput() {
+ this.$nextTick(() => {
+ const input = document.getElementById("compose-input");
+ if (input) {
+ input.focus();
+ }
+ });
+ },
async fetchContacts() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts");

diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index af65ae40..b3b25f1e 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -34,7 +34,10 @@
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
>
<!-- search + filters -->
- <div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
+ <div
+ v-if="conversations.length > 0 || isFilterActive"
+ class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2"
+ >
<div class="flex gap-1">
<input
:value="conversationSearchTerm"
@@ -188,7 +191,10 @@
</div>
<!-- no conversations at all -->
- <div v-else-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
+ <div
+ v-else-if="conversations.length === 0 && !isFilterActive"
+ class="flex flex-col text-gray-900 dark:text-gray-100"
+ >
<div class="mx-auto mb-1 text-gray-500">
<MaterialDesignIcon icon-name="tray-remove" class="size-6" />
</div>
@@ -196,11 +202,8 @@
<div>Discover peers on the Announces tab</div>
</div>
- <!-- is searching, but no results -->
- <div
- v-else-if="conversationSearchTerm !== ''"
- class="flex flex-col text-gray-900 dark:text-gray-100"
- >
+ <!-- is searching or filtering, but no results -->
+ <div v-else-if="isFilterActive" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1 text-gray-500">
<MaterialDesignIcon icon-name="magnify-close" class="size-6" />
</div>
@@ -418,6 +421,14 @@ export default {
};
},
computed: {
+ isFilterActive() {
+ return (
+ this.conversationSearchTerm !== "" ||
+ this.filterUnreadOnly ||
+ this.filterFailedOnly ||
+ this.filterHasAttachmentsOnly
+ );
+ },
blockedDestinations() {
return GlobalState.blockedDestinations;
},

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index c08c7390..49c82912 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -183,19 +183,20 @@
</div>
</header>
<div class="glass-card__body space-y-4">
- <label class="setting-toggle">
+ <label class="setting-toggle opacity-50 cursor-not-allowed">
<Toggle
id="desktop-open-calls-in-separate-window"
- v-model="config.desktop_open_calls_in_separate_window"
- @update:model-value="onDesktopOpenCallsInSeparateWindowChange"
+ :model-value="false"
+ :disabled="true"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("app.desktop_open_calls_in_separate_window")
}}</span>
- <span class="setting-toggle__description">{{
- $t("app.desktop_open_calls_in_separate_window_description")
- }}</span>
+ <span class="setting-toggle__description">
+ {{ $t("app.desktop_open_calls_in_separate_window_description") }}
+ <span class="text-blue-500 font-bold block mt-1">(Phased out for now)</span>
+ </span>
</span>
</label>
@@ -615,6 +616,47 @@
</div>
</section>
+ <!-- Sources & Infrastructure -->
+ <section class="glass-card break-inside-avoid">
+ <header class="glass-card__header">
+ <div>
+ <div class="glass-card__eyebrow">Infrastructure</div>
+ <h2>Sources & Mirroring</h2>
+ <p>Customize URLs for documentation and external resources.</p>
+ </div>
+ </header>
+ <div class="glass-card__body space-y-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Gitea Base URL</div>
+ <input
+ v-model="config.gitea_base_url"
+ type="text"
+ placeholder="https://git.quad4.io"
+ class="input-field"
+ @input="onGiteaConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ The base URL for your preferred Gitea instance.
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ Documentation Download URLs
+ </div>
+ <textarea
+ v-model="config.docs_download_urls"
+ placeholder="Enter one URL per line (or comma-separated)"
+ class="input-field min-h-[100px] text-xs font-mono"
+ @input="onGiteaConfigChange"
+ ></textarea>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ List of ZIP URLs to try when downloading documentation. One URL per line.
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- Messages -->
<section class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -904,6 +946,8 @@ export default {
blackhole_integration_enabled: true,
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
+ gitea_base_url: "https://git.quad4.io",
+ docs_download_urls: "",
},
saveTimeouts: {},
shortcuts: [],
@@ -1257,6 +1301,18 @@ export default {
);
}, 1000);
},
+ async onGiteaConfigChange() {
+ if (this.saveTimeouts.gitea) clearTimeout(this.saveTimeouts.gitea);
+ this.saveTimeouts.gitea = setTimeout(async () => {
+ await this.updateConfig(
+ {
+ gitea_base_url: this.config.gitea_base_url,
+ docs_download_urls: this.config.docs_download_urls,
+ },
+ "Infrastructure"
+ );
+ }, 1000);
+ },
async flushArchivedPages() {
if (
!(await DialogUtils.confirm(

diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index cf9c10a0..b003375e 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -396,13 +396,13 @@
<div class="flex items-center gap-4">
<a
target="_blank"
- href="https://git.quad4.io/Reticulum/rnode-flasher"
+ :href="`${giteaBaseUrl}/Reticulum/rnode-flasher`"
class="text-blue-500 hover:underline text-sm font-bold"
>RNode Flasher GH</a
>
<a
target="_blank"
- href="https://git.quad4.io/Reticulum/RNode_Firmware"
+ :href="`${giteaBaseUrl}/Reticulum/RNode_Firmware`"
class="text-blue-500 hover:underline text-sm font-bold"
>RNode Firmware GH</a
>
@@ -420,6 +420,7 @@ import Nrf52DfuFlasher from "../../js/rnode/Nrf52DfuFlasher.js";
import RNodeUtils from "../../js/rnode/RNodeUtils.js";
import products from "../../js/rnode/products.js";
import ToastUtils from "../../js/ToastUtils.js";
+import GlobalState from "../../js/GlobalState";
export default {
name: "RNodeFlasherPage",
@@ -459,6 +460,9 @@ export default {
recommendedFirmwareFilename() {
return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
},
+ giteaBaseUrl() {
+ return GlobalState.config?.gitea_base_url || "https://git.quad4.io";
+ },
},
watch: {
selectedProduct() {
@@ -473,7 +477,7 @@ export default {
async fetchLatestRelease() {
try {
const response = await fetch(
- "https://git.quad4.io/api/v1/repos/Reticulum/RNode_Firmware/releases/latest"
+ `${this.giteaBaseUrl}/api/v1/repos/Reticulum/RNode_Firmware/releases/latest`
);
if (response.ok) {
this.latestRelease = await response.json();

Served by rngit 1.3.7 - Generated in 0.14s